Add Workflow Mermaid/DOT visualization export (parity with .NET WorkflowVisualizer) - #633
Conversation
979220c to
ab56590
Compare
This comment has been minimized.
This comment has been minimized.
ab56590 to
9fd32e7
Compare
This comment has been minimized.
This comment has been minimized.
Add ToMermaidString and ToDotString in workflow/visualization.go to render a built Workflow as a Mermaid flowchart or Graphviz DOT digraph, matching .NET's WorkflowVisualizer. Nodes come from ReflectExecutors (start executor highlighted), edges from ReflectEdges: fan-out expands per target, fan-in routes through a synthesized junction, conditional edges are dashed, labels are preserved, and nested sub-workflows render as subgraphs/clusters.
9fd32e7 to
fec832a
Compare
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
Adds workflow graph visualization exporters to the Go SDK, generating human-renderable Mermaid and Graphviz DOT diagrams from the existing workflow reflection metadata (executors + edges), including support for nested sub-workflows.
Changes:
- Introduces
workflow.ToMermaidString(*Workflow)to render a Mermaidflowchart TDview of a built workflow graph. - Introduces
workflow.ToDotString(*Workflow)to render an equivalent Graphviz DOTdigraph. - Adds black-box tests covering core edge shapes (fan-out, fan-in barrier junction, conditional + labeled edges) and nested sub-workflow rendering.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| workflow/visualization.go | Implements Mermaid and DOT renderers, plus edge deduplication and escaping helpers. |
| workflow/visualization_test.go | Adds tests asserting key output fragments for both renderers, including nested sub-workflows. |
Suppressed comments (1)
workflow/visualization.go:116
- For sub-workflows, this creates a DOT cluster but doesn't define a node for the host executor (
prefix+id). Downstream edges will implicitly create a separate node outside the cluster, leaving the cluster disconnected from the workflow graph. Define the host node inside the cluster so edges attach to it predictably.
if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
fmt.Fprintf(b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(nodeID))
fmt.Fprintf(b, "%s label=\"%s\";\n", indent, dotEscape(id))
writeDotWorkflow(b, sub, prefix+id+"/", depth+1, visited)
fmt.Fprintf(b, "%s}\n", indent)
| func reflectUniqueEdges(wf *Workflow) []EdgeInfo { | ||
| seen := map[string]bool{} | ||
| var out []EdgeInfo | ||
| for _, list := range wf.ReflectEdges() { | ||
| for _, info := range list { | ||
| key := edgeSignature(info) | ||
| if seen[key] { | ||
| continue | ||
| } | ||
| seen[key] = true | ||
| out = append(out, info) | ||
| } | ||
| } | ||
| sort.Slice(out, func(i, j int) bool { | ||
| return edgeSignature(out[i]) < edgeSignature(out[j]) | ||
| }) | ||
| return out | ||
| } | ||
|
|
||
| func edgeSignature(info EdgeInfo) string { | ||
| return strings.Join(info.Connection.SourceIDs, ",") + ">" + | ||
| strings.Join(info.Connection.SinkIDs, ",") + "|" + | ||
| info.Label + "|" + strconv.FormatBool(info.HasCondition) | ||
| } |
| if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil { | ||
| fmt.Fprintf(b, "%ssubgraph %s [\"%s\"]\n", indent, nodeID, mermaidLabel(id)) | ||
| writeMermaidWorkflow(b, sub, prefix+id+"/", depth+1, visited) | ||
| fmt.Fprintf(b, "%send\n", indent) | ||
| continue | ||
| } |
| func mermaidLabel(s string) string { | ||
| return strings.ReplaceAll(s, "\"", "#quot;") | ||
| } |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by Go API Consistency Review Agent · sonnet46 · 52.7 AIC · ⌖ 5.07 AIC · ⊞ 5.7K
| } | ||
|
|
||
| func writeMermaidEdge(b *strings.Builder, indent, prefix string, info EdgeInfo) { | ||
| sources := info.Connection.SourceIDs |
There was a problem hiding this comment.
Parity gap — Mermaid fan-in node shape: Go renders fan-in nodes as {{"fan-in"}} (hexagon). Both .NET (WorkflowVisualizer) and Python (WorkflowViz) use ((fan-in)) — the circle/stadium shape — for this node, producing a visually different diagram.
.NET: lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
Python: lines.append(f"{indent}{fan_node_id}((fan-in))")
Suggested fix: change {{"fan-in"}} → ((fan-in)).
| if len(sources) > 1 { | ||
| junction := mermaidID(prefix + fanInJunctionID(sources, sinks)) | ||
| fmt.Fprintf(b, "%s%s{{\"fan-in\"}}\n", indent, junction) | ||
| for _, s := range sources { |
There was a problem hiding this comment.
Parity gap — Mermaid conditional edge default label missing: When a conditional edge has no explicit label, Go emits the edge with no label text. Both .NET and Python always emit "conditional" as the default label text for conditional edges.
.NET: string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
Python: lines.append(f"{indent}{s} -. conditional .-> {t};")
If the Go EdgeInfo.Label is empty and HasCondition is true, the edge label should fall back to "conditional" for visual parity.
| fmt.Fprintf(b, "%s}\n", indent) | ||
| continue | ||
| } | ||
| if id == wf.startExecutorID { |
There was a problem hiding this comment.
Parity gap — DOT fan-in node shape/color: Go uses shape=diamond for fan-in junction nodes. Both .NET and Python use shape=ellipse, fillcolor=lightgoldenrod for these nodes.
.NET: lines.Add($"{indent}{GetSafeId(nodeId)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"]");
Python: lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')
Also note: .NET and Python emit a default node [shape=box, style=filled, fillcolor=lightblue] for all DOT nodes, which Go omits. This means regular executor nodes render without fill in Go output.
Suggested fixes:
- Change
shape=diamond→shape=ellipse, fillcolor=lightgoldenrod - Add
node [shape=box, style=filled, fillcolor=lightblue];to the DOT header (alongside existingnode [shape=box]).
| return | ||
| } | ||
| visited[wf] = true | ||
| indent := strings.Repeat(" ", depth) |
There was a problem hiding this comment.
Parity gap — DOT start node label format: Go labels the start node as "id" (just the executor ID). Both .NET and Python label it as "id (Start)" to visually identify it as the entry point.
.NET: lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\n(Start)\"];");
Python: lines.append(f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\n(Start)"];')
Also, the start node fill color differs: Go uses #2E7D32 (dark green), while both upstream SDKs use lightgreen.
Suggested fix: change start node attributes to style=filled, fillcolor=lightgreen and append (Start) to the label.
| return "n" | ||
| } | ||
| if out[0] >= '0' && out[0] <= '9' { | ||
| return "n" + out |
There was a problem hiding this comment.
Parity gap — Mermaid label escaping is incomplete: mermaidLabel only escapes " → #quot;. Both .NET and Python escape a fuller set of characters that have special meaning in Mermaid syntax.
.NET escapes: & → &, | → |, " → ", < → <, > → >, → <br/>
Python mirrors the same set.
Of these, | is the most critical: an unescaped pipe in a label string will break Mermaid's edge-label delimiter syntax and produce invalid output. Please align mermaidLabel with the upstream escaping.
| if seen[key] { | ||
| continue | ||
| } | ||
| seen[key] = true |
There was a problem hiding this comment.
Potential stability issue — fan-in junction ID uses name concatenation instead of a hash: fanInJunctionID builds its ID by concatenating source/sink names with _ and __. Both .NET and Python derive the junction node ID from a short SHA-256 digest of the sorted source names and target, which avoids collisions when executor IDs are long, contain underscores, or differ only in ordering.
.NET: var digest = ComputeFanInDigest(target, sources); (SHA-256, first 8 hex chars)
Python: hashlib.sha256((target + "|".join(sources)).encode()).hexdigest()[:8]
With the current approach, two different fan-in configurations can produce the same junction ID if their concatenated strings happen to match, silently corrupting the graph. Consider adopting the same digest-based approach.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by Go API Consistency Review Agent · sonnet46 · 38.5 AIC · ⌖ 5.64 AIC · ⊞ 6K
| } | ||
| return | ||
| } | ||
| for _, s := range sources { |
There was a problem hiding this comment.
Parity issue — fan-in node shape (Mermaid): Go emits a hexagon {{"fan-in"}} for fan-in junction nodes, but upstream .NET WorkflowVisualizer uses a circle/stadium ((fan-in)) (dotnet source). The visual convention should match: please change the Mermaid fan-in shape to (("fan-in")).
| junction := mermaidID(prefix + fanInJunctionID(sources, sinks)) | ||
| fmt.Fprintf(b, "%s%s{{\"fan-in\"}}\n", indent, junction) | ||
| for _, s := range sources { | ||
| fmt.Fprintf(b, "%s%s --> %s\n", indent, mermaidID(prefix+s), junction) |
There was a problem hiding this comment.
Parity issue — conditional edge default label (Mermaid): .NET emits a default label "conditional" on conditional edges that have no user-supplied label, using the syntax -. conditional .-> (dotnet source). Go currently emits no label and uses -.-> syntax. Please add the default "conditional" label when info.Label == "" and use the -. label .-> Mermaid syntax to match upstream.
| fmt.Fprintf(b, "%send\n", indent) | ||
| continue | ||
| } | ||
| fmt.Fprintf(b, "%s%s[\"%s\"]\n", indent, nodeID, mermaidLabel(id)) |
There was a problem hiding this comment.
Parity issue — start node label: .NET appends (Start) to the start executor label in both Mermaid and DOT (e.g. "myExecutor\n(Start)"). Go emits only the executor ID. This makes it harder to distinguish the start node from other nodes purely by label. Please append \n(Start) (DOT) or (Start) (Mermaid) to the start node label to match upstream.
| sinks := info.Connection.SinkIDs | ||
| var attrParts []string | ||
| if info.Label != "" { | ||
| attrParts = append(attrParts, fmt.Sprintf("label=\"%s\"", dotEscape(info.Label))) |
There was a problem hiding this comment.
Parity issue — DOT fan-in node shape: Go uses shape=diamond for fan-in junction nodes. Upstream .NET uses shape=ellipse (dotnet source). Please change to shape=ellipse to preserve visual parity.
| if visited[wf] { | ||
| return | ||
| } | ||
| visited[wf] = true |
There was a problem hiding this comment.
Parity issue — DOT global node style: .NET emits node [shape=box, style=filled, fillcolor=lightblue]; so all nodes get a light-blue fill by default, with the start node overriding to fillcolor=lightgreen. Go omits the global style and only fills the start node. Please add a global node style matching the .NET convention so non-start nodes are also filled.
| func mermaidLabel(s string) string { | ||
| return strings.ReplaceAll(s, "\"", "#quot;") | ||
| } | ||
|
|
There was a problem hiding this comment.
Parity issue — Mermaid label escaping: mermaidLabel only escapes " (as #quot;). Upstream .NET EscapeMermaidLabel also escapes & → &, | → |, < → <, > → >, \n → <br/>, and strips \r (dotnet source). Unescaped | and </> will break Mermaid parsing for executor names containing those characters. Please bring mermaidLabel in line with the full upstream escaping set.
Parity Review — PR #633: Workflow Mermaid/DOT VisualizationThis PR is in scope (adds exported public API: The feature concept is well-aligned with upstream: both .NET ( However, four parity issues were found: 1. Missing
|
There was a problem hiding this comment.
Generated by Go API Consistency Review Agent · sonnet46 · 56.2 AIC · ⌖ 4.72 AIC · ⊞ 6K
|
|
||
| // ToMermaidString renders wf as a Mermaid flowchart definition. | ||
| // | ||
| // It mirrors .NET's WorkflowVisualizer.ToMermaidString: nodes are emitted for |
There was a problem hiding this comment.
Parity gap: include_internal_executors option missing
The Python WorkflowViz.to_digraph() and to_mermaid() both accept include_internal_executors: bool = False (see _viz.py). This parameter controls whether internal (framework-managed) executors are included in the rendered graph. Its default is False, meaning internal executors are hidden by default.
The Go ToMermaidString and ToDotString functions have no equivalent option. Callers cannot opt in to showing internal executors, and there is no parity with the Python default of excluding them if Go's reflection helpers surface internal executors unconditionally.
Suggestion: introduce a VisualizationOptions struct (or a simple boolean) defaulting to false, matching the Python default. Note that the .NET WorkflowVisualizer also does not expose this parameter — that cross-SDK gap already exists upstream — but Go should at minimum align with Python here.
| binding := wf.executorBindings[id] | ||
| nodeID := prefix + id | ||
| if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil { | ||
| fmt.Fprintf(b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(nodeID)) |
There was a problem hiding this comment.
DOT fan-in node shape diverges from .NET
The .NET WorkflowVisualizer emits fan-in junction nodes as shape=ellipse, fillcolor=lightgoldenrod (see WorkflowVisualizer.cs). The Go implementation uses shape=diamond with no fill color. The Python implementation also uses shape=ellipse, fillcolor=lightgoldenrod.
Suggestion: change the junction node to shape=ellipse, fillcolor=lightgoldenrod to match both .NET and Python.
| return | ||
| } | ||
| for _, s := range sources { | ||
| for _, t := range sinks { |
There was a problem hiding this comment.
DOT start-node styling diverges from .NET and Python
Both .NET (WorkflowVisualizer.cs) and Python (_viz.py) emit the start node with fillcolor=lightgreen and label="<id>\n(Start)". The Go implementation uses fillcolor="#2E7D32", fontcolor="white" without the (Start) label suffix.
Suggestion: use fillcolor=lightgreen and append \n(Start) to the label to match the upstream visual convention. (The Mermaid side already appends a classDef startNode with the hex green — it's the DOT path that diverges.)
| fmt.Fprintf(b, "%sclass %s startNode;\n", indent, nodeID) | ||
| } | ||
| } | ||
| for _, info := range reflectUniqueEdges(wf) { |
There was a problem hiding this comment.
Conditional edge default label: Go omits "conditional" when no custom label is set
In the Mermaid output for conditional edges, .NET uses -. conditional .-> when no custom label is set (WorkflowVisualizer.cs). Python likewise emits -. conditional .-> for unlabeled conditional edges.
The Go writeMermaidEdge uses "-.->" without inserting any label text when info.Label == "". This means an unlabeled conditional edge in Go renders as A -.-> B instead of A -. conditional .-> B.
Suggestion: When info.HasCondition && info.Label == "", default the label to "conditional" to match .NET and Python.
What
Adds
workflow/visualization.goexporting two functions:func ToMermaidString(wf *Workflow) string— renders a builtWorkflowas a Mermaidflowchart TD.func ToDotString(wf *Workflow) string— renders the same graph as a Graphviz DOTdigraph.Both consume the existing reflection API (
ReflectExecutors,ReflectEdges,EdgeInfo,EdgeConnection) — no production data structures were changed.Why (parity)
.NET's
WorkflowVisualizer.ToDotString/ToMermaidStringis a real, shipped capability, andworkflow/telemetry.goalready serializes the graph to JSON but stops short of emitting a human-renderable diagram. This closes that cross-SDK gap so Go workflows get the same visualization affordance qmuntal values for alignment. The renderers mirror the .NET semantics:len(SourceIDs) > 1) route through a synthesized junction node.HasCondition) are dashed;EdgeInfo.Labelis preserved.ExecutorBinding.RawValueis a*Workflow, as produced byinproc.BindSubworkflowAsExecutor) render as Mermaid subgraphs / DOT clusters via recursion.Node IDs are sanitized for Mermaid and labels are escaped for DOT. Output is deterministic (executors and edges sorted; fan-in edges deduplicated since
ReflectEdgesregisters them under every source).Tests
workflow/visualization_test.go(black-boxworkflow_test, reusing the existingnewNoOpExecutorharness):go build ./...,go vet ./workflow/, andgo test ./workflow/...all pass.Open design questions
Opening as a draft since this adds public API surface:
package workflow, or methods on*Workflow, or a separateworkflow/visualizationsubpackage? Kept them as package-level funcs to mirror the staticWorkflowVisualizerhelpers.{{"fan-in"}}junction (Mermaid) /diamondnode (DOT); fan-out is left as parallel edges without a junction. Worth confirming this matches the .NET visual convention.